# ----------------------------------------------------------------------------
#   ERKbJockey.rb  Keyboard Jockey commands
# ----------------------------------------------------------------------------
# keyboard command processor.

# Version 2.0 2009/12/08

# This script requires EventRelay.dll version 1.1 or later.
# This code is distributed under the GPL ver.2 license with no warranty or
# guanantees whatsoever.
# CopyRight (c) 2008 Pecan Heber
# CopyRight (c) 2009 Pecan Heber

# Commands can be any length.

# Each letter of a command must be entered within "INTER_CHARACTER_TIMEOUT" time.
# An interval greater than "INTER_CHARACTER_TIMEOUT" completes a command.
# The Enter/Return key also completes a command. The ESC key cancels a command.
# The BackSpace key erases the last entered command character and cancels the
# "INTER_CHARACTER_TIMEOUT" until another key is entered.

# See ERUserCommands::initialize() (below) for examples of commands handled by
# the current version.

require "sketchup.rb"
require "EventRelay.dll"

# ----------------------------------------------------------------------------
class ERKbJockey < EventRelay
# ----------------------------------------------------------------------------

    VK_RETURN = 13;
    VK_ESC = 27;
    VK_BACKSPACE = 8;

    # OnKeyUp/Down key modifiers
    KEYMOD_NONE      = 0x0000
    KEYMOD_ALT       = ALT_MODIFIER_MASK;
    KEYMOD_CONTROL   = COPY_MODIFIER_MASK;
    KEYMOD_SHIFT     = CONSTRAIN_MODIFIER_MASK;

    # seconds to wait for next character
    INTER_CHARACTER_TIMEOUT = 0.8;

    SUPPRESS = true
    ALLOW = false


    # ----------------------------------
    #    initialize
    # ----------------------------------
    def initialize
        super()
        @ERKbJockeyInInit = true;
        @versionStr = ERGetVersion();
        @verMajor, @verMinor, @verFixLevel, @verDate = @versionStr.split(%r{[\. ]});
        #//Logit("ERKbJockey version string #{@versionStr}")
        #//Logit("ERKbJockey version Maj:#{@verMajor},Min:#{@verMinor},Fix:#{@verFixLevel},Date:#{@verDate}")

        if ( (@verMinor.to_i() < 1 ) && ( @verMajor.to_i() < 2) )
            msg = sprintf("EventRelay Current Version: %d.%d\n", @verMajor, @verMinor)
            msg <<" ERKbJockey.rb requires EventRelay.dll version 1.1 or greater."
            UI.messagebox(msg)
            # remove self from EventRelay queue of notify objects
            ERRemoveObject(self);
        end

        @kbCmdTable = Hash[] # Hash of ["command", method name]
        @kbObjTable = Hash[] # Hash of ["command", object_id]
        @kbInput = ""
        @bPlayBack = false;
        @bRecording = false;

        @toolsObserver = MyToolsObserver.new
        Sketchup.active_model.tools.add_observer(@toolsObserver)

        #//Logit "-----ERKbJockey.rb initialized-----"
    end
    # ----------------------------------
    #    OnKeyDown
    # ----------------------------------
    def OnKeyDown(keyIn,repeat,flags)
        key = keyIn
        # Ignore keys with ALT, shift and CTRL
        if ( 0 != (flags & (KEYMOD_ALT|KEYMOD_SHIFT|KEYMOD_CONTROL)) )
            return ALLOW;
        end
        # force letters to lower case
        if ( (key >= ?A) && (key <= ?Z) )
            key = (keyIn | 0x20)
        end
        # Debugging
        #//Logit "OnKeyDown key:#{keyIn} char:#{key.chr} repeat:#{repeat} flags:#{flags}"
        #- vk_key, keyMacro = GetKeyInfo(key, flags)
        #- Logit("KeyInfo: key:#{keyIn} vk_key:#{vk_key} keyMacro:{#{keyMacro}}")

        # Kill the inter-character timer if not in playback mode
        if ( not @bPlayBack )
            ERTimerStop();
        end

        # if active tool is any TextTool, allow all keys
        if( @toolsObserver.getActiveTool.include?("TextTool") )
            return ALLOW;
        end
        # if in playback mode, allow all keys until {ENTER}
        if ( @bPlayBack )
            if ( key == VK_RETURN )
                SetPlayBack( false )
                ERTimerStop();
                return SUPPRESS # fix 1.0.01 2008/12/2
            end
            return ALLOW
        end
        # Dont parse {ENTER} when no chached input # fix 1.0.05 2008/12/8
        # A single {ENTER} can be used to turn off PlayBack mode
        # But sometimes splats SketchUp when a selection is active.
        # So this routine contains an initialization guard.
        if ( (@kbInput.length == 0) && (key == VK_RETURN) )
                                  #//(key < ?\ ) || (key > ?~) ) #space to tilde
            SetPlayBack( false );
            if ( @ERKbJockeyInInit ) then
                return SUPPRESS; end;
            return ALLOW;
        end
        # backspace
        if ( (@kbInput.length > 0) && (key == VK_BACKSPACE) )
            @kbInput = @kbInput.chop
            ShowStatusText("Command: " << @kbInput)
            return SUPPRESS
        end

        # a return/enter key ends command assembly
        if ( (@kbInput.length > 0) && (key == VK_RETURN) )
            # execute valid assembled multi-char keyboard commands
            #-if ( (@kbInput.length > 1) && (@kbObjTable[@kbInput]) )
            if ( @kbObjTable[@kbInput] )        # mod 1.0.02
                # call routine from command table. Turn on PlayBack flag
                # to protect us from possible SendKeyMacro() keys
                #--SetPlayBack( true );
                begin
                    @kbObjTable[@kbInput].send(@kbCmdTable[@kbInput])
                    ShowStatusText("Command: #{@kbInput} executed");
                rescue Exception => detail
                    ShowStatusText("Command: #{@kbInput} error");
                    #//Logit( detail.message )
                    print detail.message
                    #//Logit( detail.backtrace.join("\n") )
                    print detail.backtrace.join("\n")
                end
                ClearRecording();
                # send a timed {ENTER} to get us out of playback mode
                ERTimerStart(1);        # fix 1.0.04
                return SUPPRESS;
            end
            # Not a valid mult-char keyboard command
            if ( @kbInput.length > 1 )
                # misspelled multi-char command
                ShowStatusText("Command: #{@kbInput} unknown");
            else
                # Sketchup single char command followed by {ENTER}
                PlayRecording( @kbInput );
            end
            ClearRecording();
            return SUPPRESS;
        end #if VK_RETURN

        # ESC key input
        if ((@kbInput.length > 0) && (key == VK_ESC) )
            # discard the assembled command
            ClearRecording();
            ShowStatusText("Command: ")
            return SUPPRESS;
        end

        # Assemble command and test key against commands table
        if ( ScanCmdTable( @kbInput + key.chr ) )
            AppendRecording( key.chr );
            # start a timer so we don't wait on input forever.
            #//if ( 1 == @kbInput.length )
                ERTimerStart( INTER_CHARACTER_TIMEOUT );
            #//end
            return SUPPRESS
        end
        # Ignore Unknown multi-letter commands
        if ( (@kbInput.length+1) > 1 )
            ShowStatusText("Command: #{@kbInput<<key.chr} unknown");
            ClearRecording();
            return SUPPRESS;
        end
        # Not a char in one of our commands but may be
        # a single letter SketchUp command
        if ( @kbInput.length > 0 )
            PlayRecording( @kbInput << key.chr );
            ClearRecording();
            return SUPPRESS;
        end
        ClearRecording();
        return ALLOW;
    end
    # ----------------------------------
    #    ScanCmdTable
    # ----------------------------------
    def ScanCmdTable( cmdString )
        found = false;
        @kbCmdTable.each_key{ |key|
            if ( key[0,cmdString.length] == cmdString  )
                found = true;
                break;
            end
        }
        #//Logit("ScanCmdTable found=#{found} for #{cmdString}" )
        return found;
    end
    # ----------------------------------
    #    AppendRecording
    # ----------------------------------
    def AppendRecording( keyChar )
        #//Logit("Recording #{keyChar} at #{@kbInput.length}")
        @bRecording = true
        @kbInput <<  keyChar ;
        ShowStatusText( "Command: "<< @kbInput );
    end
    # ----------------------------------
    #    ClearRecording
    # ----------------------------------
    def ClearRecording()
        #//Logit("ClearRecording")
        @bRecording = false;
        @kbInput = "";
    end
    # ----------------------------------
    #    PlayRecording
    # ----------------------------------
    def PlayRecording(sKeyInput)
        if (sKeyInput.length > 0)
            #//Logit("PlayRecording Arg[#{sKeyInput}]");
            ShowStatusText( "Executed: " << @kbInput);
            SetPlayBack ( true );
            #Timer will cancel PlayBack if a new window eats our {ENTER}
            ERTimerStart(2);
            #{ENTER} gets us out of PlayBack mode
            SendKeyMacro(sKeyInput << "{ENTER}");
        end
    end
    # ----------------------------------
    #    SetPlayBack
    # ----------------------------------
    def SetPlayBack(state)
        @bPlayBack = state;
        #//msg = @bPlayBack ? "TRUE" : "FALSE";
        #//Logit("PlayBack [#{msg}]");
    end
    # ----------------------------------
    #    OnERTimerEvent
    # ----------------------------------
    def OnERTimerEvent()
        SetPlayBack( false );
        if ( @bRecording )
            if ( @kbInput.length > 0)
                # Enter key will end recording mode
                Logit("Timer popped with #{@kbInput}")
                SendKeyMacro("{ENTER}")
            end
        end
    end
    # ----------------------------------
    #    Menus
    # ----------------------------------
    def OnMenuSelected(menuId, menuLabel)
        # Make sure the tools observer is re-instated across file new/open/save as
        #//Logit "ERCopyMove:OnMenuSelected MenuId:#{menuId} label:#{menuLabel}"
        @toolsObserver.checkToolsObserver()
        # Just before executing a menu, always turn off PlayBack mode
        SetPlayBack( false );
        return ALLOW
    end #OnMenuSelected
    # ----------------------------------
    #    Mouse OnRButtonDown
    # ----------------------------------
    def OnRButtonDown(flags, x, y)
        if GetInitDone()
            return ALLOW;
        else
            return SUPPRESS;
        end
    end #OnRButtonDown
    # ----------------------------------
    #    RegisterCommand
    # ----------------------------------
    def RegisterCommand( cmdIn, objIn, methodIn)
        #//Logit("RegisterCommand #{cmdIn} #{objIn} #{methodIn}");
        if ( (not cmdIn.empty?) && (objIn.respond_to?(methodIn)) )
            #//Logit("#{cmdIn} #{objIn.class} #{methodIn}")
            @kbObjTable[cmdIn] = objIn;
            @kbCmdTable[cmdIn] = methodIn;
        else
            Logit("ERKbJockey::RegisterCommand error for #{cmdIn} #{methodIn}")
            puts("ERKbJockey::RegisterCommand error for #{cmdIn} #{methodIn}")
        end
    end
    # ----------------------------------
    #    ShowStatusText
    # ----------------------------------
    def ShowStatusText( aString)
        Sketchup.set_status_text( aString, SB_PROMPT);
    end

    # ----------------------------------
    #    GetCommandTable
    # ----------------------------------
    def GetCommandTable()
        return @kbCmdTable;
    end
    # ----------------------------------
    #    SetInitDone()
    # ----------------------------------
    def SetInitDone(state)
        if (state)
            @ERKbJockeyInInit = false;
        else
            @ERKbJockeyInInit = true;
        end
    end
    # ----------------------------------
    #    GetInitDone()
    # ----------------------------------
    def GetInitDone()
        return (false == @ERKbJockeyInInit);
    end

end # class ERKbJockey
# ----------------------------------------------------------------------------
class MyToolsObserver < Sketchup::ToolsObserver
# ----------------------------------------------------------------------------
    # We need a Tools Observer to tell us when any TextTool
    # is active.

    def initialize
        super()
        @currentModelId = Sketchup.active_model.object_id
        @currentTool = "notool"
        @currentToolState = 0
    end

    def onActiveToolChanged(tools_object, toolname, toolid)
        @currentTool = toolname
        #-Logit "Active Tool Changed: #{@currentTool}"
    end
    def onToolStateChanged(tools_object, toolname, toolid, state)
        @currentTool = toolname
        @currentToolState = state
        #-Logit "Tool State Changed: #{@currentTool} #{state}"
    end

    def checkToolsObserver
        if ( @currentModelId != Sketchup.active_model.object_id)
            #-printf("old[%d] new[%d]\n", @currentModelId, Sketchup.active_model.object_id)
            Sketchup.active_model.tools.remove_observer(self) # necessary to avoid wierd crashes
            @currentModelId = Sketchup.active_model.object_id
            Sketchup.active_model.tools.add_observer(self)
        end
    end

    def getActiveTool
        return @currentTool
    end

    def getToolState
        return @currentToolState
    end

end # class MyToolsObserver
# ----------------------------------------------------------------------------
class ERMakeTempGC
# ----------------------------------------------------------------------------
    # Make a temporary group

    def initialize()
    end

    def makeAGroup
        width=1; height=1; depth=1;
        model = Sketchup.active_model
        model.start_operation("")
        entities = model.active_entities

        # If you wanted the box to be created as simple top level entities
        # rather than a Group, you could comment out the following two lines.
        group = entities.add_group
        entities = group.entities

        pts = []
        pts[0] = [0, 0, 0]
        pts[1] = [width, 0, 0]
        pts[2] = [width, depth, 0]
        pts[3] = [0, depth, 0]
        base = entities.add_face pts
        height = -height if( base.normal.dot(Z_AXIS) < 0 )

        # Now we can do the pushpull
        base.pushpull height

        # Now we are done and we can end the operation
        model.commit_operation
        return group
    end

end # class ERMakeTempGC
#// ----------------------------------------------------------------------------
    #-require "sketchup.rb"
    #-require "ERKbJockey.rb"
# ----------------------------------------------------------------------------
class ERUserCommands
# ----------------------------------------------------------------------------

    # ----------------------------------
    #    initialize
    # ----------------------------------
    def initialize
        super()
        @InitLevel = 0;
        @focusCount = 0;
        @myERKbJockey = ERKbJockey.new();
        #--@myERKbJockey.ERSendAction(menuID); #debugging
        #//@myERKbJockey.ERSendMenuAction("Window/Ruby Console"); #debugging

        # Zoom out/extents/window and internal commands
        @myERKbJockey.RegisterCommand( "zo", self, :ZoomOutCommand )
        @myERKbJockey.RegisterCommand( "ze", self, :ZoomExtentsCommand )
        @myERKbJockey.RegisterCommand( "zw", self, :ZoomWindowCommand )
        @myERKbJockey.RegisterCommand( "z",  self, :ZoomExtentsCommand )
        @myERKbJockey.RegisterCommand( "shortcuts", self, :ShortcutsCommand )
        @myERKbJockey.RegisterCommand( "commands",  self, :ShowCommandTable )
        @myERKbJockey.RegisterCommand( "cmds",      self, :ShowCommandTable )
        @myERKbJockey.RegisterCommand( "help",      self, :ShowAllCommands )
        @myERKbJockey.RegisterCommand( "config",    self, :KbCmdConfig )
        #//@myERKbJockey.RegisterCommand( "luc",       self, :LoadCommandConfig )

        LoadCommandConfig(); #User defined commands via menu dialog
        @InitLevel = 0;
        @myERKbJockey.SetInitDone(true);
    end # initialize

    # ----------------------------------
    #    Logit //debugging
    # ----------------------------------
    def Logit(msg)
        @myERKbJockey.Logit(msg);
    end

    # ----------------------------------
    #    ZoomOutCommand
    # ----------------------------------
    def ZoomOutCommand(a = 0.3)  #ala Jim Foltz
        #//@myERKbJockey.Logit("ZoomOutCommand executed");
        m = Sketchup.active_model
        v = m.active_view
        c = v.camera
        t = c.target
        e = c.eye
        up = c.up
        v = t - e
        v.length = v.length * a
        c.set e-v, t, up
    end
    # ----------------------------------
    #    ZoomExtentsCommand
    # ----------------------------------
    def ZoomExtentsCommand()     #ala Jim Foltz
        #//@myERKbJockey.Logit("ZoomExtentsCommand executed");
        m = Sketchup.active_model
        v = m.active_view
        s = m.selection
        if ! s.empty?
            v = v.zoom s
        else
            v.zoom_extents
        end
        ZoomOutCommand(0.3)
    end
    # ----------------------------------
    #    ZoomWindow
    # ----------------------------------
    def ZoomWindowCommand()
        #//@myERKbJockey.Logit("ZoomWindow executed");
        #- @myERKbJockey.SendKeyMacro("!c{DOWN 12}{ENTER}")
        result = Sketchup.send_action "selectZoomWindowTool:"
    end
    # ----------------------------------
    #    Show Shortcuts
    # ----------------------------------
    def ShortcutsCommand()
        shortcuts = Sketchup.get_shortcuts
        if (shortcuts)
            for i in 0..shortcuts.length
                next if (nil == shortcuts[i])
                #//@myERKbJockey.Logit(shortcuts[i].inspect())
                posn = shortcuts[i].index(%r{\s})
                keys = shortcuts[i][0,posn]
                path = shortcuts[i][posn+1, shortcuts[i].length];
                #//@myERKbJockey.Logit("#{keys} [#{posn}] #{path}")
                shortcuts[i] = sprintf("%-40s\t%s\n", path, keys)
            end
            shortcuts.sort!
            UI.messagebox(shortcuts,MB_MULTILINE,"Shortcuts")
        end
    end
    # ----------------------------------
    #    Show Command Table
    # ----------------------------------
    def ShowCommandTable()
        cmdAry = @myERKbJockey.GetCommandTable();
        showAry = Array.new(cmdAry.size);
        i = 0;
        cmdAry.each_pair{ |key, value |
            showAry[i] = "#{key}   #{value}\n"
            showAry[i] = sprintf("%-24s\t%s\n", key, value)
            i += 1;
        }
        showAry.sort!
        UI.messagebox(showAry, MB_MULTILINE, "KB Commands")
    end
    # ----------------------------------
    #    Show All Commands (Help)
    # ----------------------------------
    def ShowAllCommands()
        ShowCommandTable();
        ShortcutsCommand();
    end
    # ----------------------------------
    #    Command Key Config
    # ----------------------------------
    # Start user command config dialog, then load the resulting .rb file
    def CommandKeyConfig()
        #//Logit("Starting User Command Config: ERCmdKeyConfig");
        count = @myERKbJockey.ERCmdKeyConfig();
        #//Logit("CommandKeyConfig count #{count}");
        if (count > 0) then LoadCommandConfig(); end;
    end
    # ----------------------------------
    #    Load Command Config
    # ----------------------------------
    # Load UserKBCommands.rb
    def LoadCommandConfig()
        begin
            fileName = File.join(File.dirname(__FILE__), "EventRelay", "UserKBCommands.rb")
            load fileName;
            $".delete_if {|x| x == fileName }
            userkbCmds = UserKbCommandDefinitions.new(@myERKbJockey);
            Logit("LoadCommandConfig #{fileName}")
        rescue Exception => detail
            msg = "Error loading: #{fileName}";
            #@myERKbJockey.ShowStatusText(msg); #<-- overwritten by command end msg
            puts msg;
            Logit( msg );
            Logit( detail.message )
            print detail.message
            Logit( detail.backtrace.join("\n") )
            print detail.backtrace.join("\n")
        end
        #//@kbJockey.ShowCommandTable(); #//inspect
    end
    # ----------------------------------
    #    KbCmdConfig
    # ----------------------------------
    # internal utility to shadow SU menu structure which
    # is used to populate the UserKBCommands dialog tree.
    def KbCmdConfig()
        # Restart for previously interrupted initialization
        if ( (@InitLevel<4) && (@InitLevel>0) )
            @InitLevel == 0;
        end;
        # Begin config initialization
        if ( @InitLevel == 0 )
            @InitLevel += 1;
            @myERKbJockey.SetInitDone(false);
        end;
        # Ensure SketchUp (not RubyConsole) has the focus, else Ruby will
        # eat our command keys.
        if ( not @myERKbJockey.ERSetFocusSketchUp() )
            #//Logit("@focusCount #{@focusCount}");
            @focusCount += 1;
            if (@focusCount < 4)
                id = UI.start_timer(0.5, false) {self.KbCmdConfig};
                return;
            end;
        end;

        menuId = @myERKbJockey.ERGetMenuID("Edit"); # Scan the menu structure

        if ( @InitLevel == 1 )
            @InitLevel += 1;
            # We've already scanned once to get "unmodified" menu items.
            # Now get the Group/Component menu items by
            # Selecting a temporary Group and Component, then
            # scan the menu structure in order to memorize their associated
            # menu items
            @InitModel = Sketchup.active_model
            # preserve the current selection
            @selection = Sketchup.active_model.selection
            @oldSelAry = @selection.to_a();
            #//Logit("Starting Old Selection count #{@oldSelAry.length()}")
            @tempGC = ERMakeTempGC.new
            @tempGroup = @tempGC.makeAGroup()
            @tempComponent = @tempGC.makeAGroup.to_component
            @selection = Sketchup.active_model.selection
            @selection.clear
            @selection.add @tempGroup
            # update shadow menus for Groups
            @myERKbJockey.SendKeyMacro("!e{ALT}")
            @myERKbJockey.SendKeyMacro("config{ENTER}")
            return;
        end
        if ( @InitLevel == 2 )
            @InitLevel += 1;
            # Back from Group scan. Now scan Component menu items
            @selection.clear
            #//@tempGroup.erase!;
            #//@tempGroup = nil;
            # Now scan for Component selection menu items
            @selection.add @tempComponent
            # Send single {Enter} to clear PlayBack Mode set by previous "config"
            @myERKbJockey.SendKeyMacro("!e{ALT}")
            @myERKbJockey.SendKeyMacro("config{ENTER}")
            return;
        end
        if ( @InitLevel == 3 )
            @InitLevel += 1;
            # Back from Component scan. Cleanup initialization.
            @selection.clear
            4.times{ Sketchup.undo};
            #//@tempGroup.erase!;
            @tempGroup = nil;
            #//@tempComponent.erase!;
            @tempComponent = nil;
            @tempGC = nil;
            @selection.clear
            @oldSelAry.each{ |entity| @selection.add entity}
            #//Logit("Reset Selection count #{@selection.count()}")
        end
        if ( @InitLevel == 4 )
            @myERKbJockey.SetInitDone(true);
            CommandKeyConfig(); #User defined commands via menu dialog
        end

    end # KbCmdConfig()

end #ERUserCommands
# ----------------------------------------------------------------------------

 unless file_loaded?("ERKbJockey.rb")
     myERUserCommands = ERUserCommands.new;
     UI.menu("Plugins").add_item("KB CmdConfig") { myERUserCommands.KbCmdConfig() }
     file_loaded "ERKbJockey.rb"
 end

# ----------------------------------------------------------------------------
# fix 1.0.01 2008/12/2
#   Timed out undefined command followed by {ENTER} cause SketchUp to delete
#   the selection during move/copy
# mod 1.0.02 2008/12/5
#   Allow single letter commands in command table
# mod 1.0.03 2008/12/6
#   Added ERGetMenuId("menu/path") for Sketchup.send_action(menuId)
#   See ViewHiddenGeometryCommand() for an example.
# fix 1.0.04 2008/12/6
#   Added ERTimerStart(1) after method execution to get out of
#   bPlayback mode. Dlg windows eat the {Enter}, leaving us
#   in permanent bPlayBack. The Timer pop turns off bPlayBack.
#
# fix 2.0.05 2008/12/8
#   An {ENTER} was being parsed when we had no input cached
#   causing a crash (finding where in Ruby is impossible)
#
# mod 2.0.05 2008/12/8
#   Add/Select Group/Component at startup to grab their menu items
#
# fix 2.0.06 2008/12/10
#   Crashes when {ENTER} followed a Group/Component selection
#   during initialization.
#   Allow all ascii chars into menu selections. Keep all chars less than space
#   and greater than tilde from entering command parsing.
#
# mod 2.0.07 2008/12/11
#   Invoke ScanMenuBarCommand() via a delay timer to avoid clashing with
#   Ruby Console initialization. Force ReFocus to SketchUp with new routine
#   ERSetFocusSketchUp()
#
# mod 2.0.08 2008/12/23
#   Moved cmd config initialization out of script initialization
#   and optimized to avoid MSW duplicate menu scanning.
#
# mod 2.0.09 2009/01/8
#   Added "KB CmdConfig" to plugin menu
#
# fix 2.0.10 2009/01/9
#   OnRMouseDown to guard KbCmdConfig initialization
#   Restartable KbCmdConfig
#
# ----------------------------------------------------------------------------
# ToDo
# Hide appearance of temp Group and Component. Off the screen maybe?
#

